﻿namespace MetaDrive.VisualStudio.Services
{
    using System;
    using System.Collections.Generic;
    using System.Globalization;
    using System.IO;
    using System.Linq;
    using System.Runtime.InteropServices;
    using EnvDTE;    
    using Constants = EnvDTE.Constants;

    public class DteProcessor
    {
        private readonly IServiceProvider _serviceProvider;
        private readonly DTE _dte;
        private readonly string _templatePath;
        private readonly LogStore _logStore;
        
        public DteProcessor(IServiceProvider serviceProvider, string templatePath)
        {
            _serviceProvider = serviceProvider;
            _templatePath = templatePath;
            _dte = serviceProvider.GetService<EnvDTE.DTE>();
            _logStore = new LogStore(templatePath, _dte);
        }

        public DteProcessor(DTE dte, string templatePath)
        {
            _dte = dte;
            _templatePath = templatePath;
            _logStore = new LogStore(templatePath, _dte);
        }

        /// <summary>
        /// Writes generated content to output files and deletes the old files that 
        /// were not regenerated.
        /// </summary>
        /// <param name="outputFiles">
        /// A collection of <see cref="OutputFile"/> objects.
        /// </param>
        /// <remarks>
        /// <see cref="OutputManager"/> calls this method to update generated output 
        /// files and delete old files that were not regenerated. This method accesses
        /// Visual Studio automation model (EnvDTE) to automatically add generated 
        /// files to the solution and source control.
        /// </remarks>        
        public void UpdateFiles(ICollection<OutputFile> outputFiles)
        {
            string previousDirectory = Environment.CurrentDirectory;
            Environment.CurrentDirectory = Path.GetDirectoryName(_templatePath);

            try
            {                
                IEnumerable<Project> projects = _dte.Solution.GetProjects();
                ProjectItem template = _dte.Solution.FindProjectItem(_templatePath);

                Validate(outputFiles, projects);

                _logStore.Write(outputFiles);
                
                DeleteOldOutputs(outputFiles, _dte.Solution, template);
                UpdateOutputFiles(outputFiles, _dte.Solution, projects, template);
            }
            finally
            {
                Environment.CurrentDirectory = previousDirectory;
            }
        }

        /// <summary>
        /// Saves content accumulated by this transformation to the output files.
        /// </summary>
        /// <param name="outputFiles">
        /// <see cref="OutputFile"/>s that need to be added to the <paramref name="solution"/>.
        /// </param>
        /// <param name="solution">
        /// Current Visual Studio <see cref="Solution"/>.
        /// </param>
        /// <param name="projects">
        /// All <see cref="Project"/>s in the current <paramref name="solution"/>.
        /// </param>
        /// <param name="template">
        /// A <see cref="ProjectItem"/> that represents T4 template being transformed.
        /// </param>
        /// <remarks>
        /// Note that this method currently cannot distinguish between files that are
        /// already in a Database project and files that are simply displayed with 
        /// "Show All Files" option. Database project model makes these items appear 
        /// as if they were included in the project.
        /// </remarks>
        private void UpdateOutputFiles(IEnumerable<OutputFile> outputFiles, Solution solution, IEnumerable<Project> projects, ProjectItem template)
        {
            foreach (OutputFile output in outputFiles)
            {
                UpdateOutputFile(output); // Save the output file before we can add it to the solution

                //ProjectItem outputItem = solution.FindProjectItem(output.File);
                ProjectItem outputItem = solution.FindProjectItem(output.File);
                ProjectItems collection = FindProjectItemCollection(output, projects, template);

                if (outputItem == null)
                {
                    // If output file has not been added to the solution
                    outputItem = collection.AddFromFile(output.File);
                }
                //else if (!Same(outputItem.Collection, collection))
                else if (!outputItem.Collection.Same(collection))
                {
                    // If the output file moved from one collection to another                    
                    string backupFile = output.File + ".bak";
                    File.Move(output.File, backupFile); // Prevent unnecessary source control operations
                    outputItem.Delete(); // Remove doesn't work on "DependentUpon" items
                    File.Move(backupFile, output.File);

                    outputItem = collection.AddFromFile(output.File);
                }
                
                outputItem.SetProperties(output);
                outputItem.SetProjectItemBuildProperties(output.BuildProperties);
                outputItem.AddReferences(output);
            }
        }

        /// <summary>
        /// Adds a folder to a specified <paramref name="collection"/> of project items.
        /// </summary>
        /// <param name="collection">
        /// A <see cref="ProjectItems"/> collection that belongs to a <see cref="Project"/> or 
        /// <see cref="ProjectItem"/> of type <see cref="Constants.vsProjectItemKindPhysicalFolder"/>.
        /// </param>
        /// <param name="folderName">
        /// Name of the folder to be added.
        /// </param>
        /// <param name="basePath">
        /// Absolute path to the directory where the folder is located.
        /// </param>
        /// <returns>
        /// A <see cref="ProjectItem"/> that represents new folder added to the <see cref="Project"/>.
        /// </returns>
        /// <remarks>
        /// If the specified folder doesn't exist in the solution and the file system, 
        /// a new folder will be created in both. However, if the specified folder 
        /// already exists in the file system, it will be added to the solution instead. 
        /// Unfortunately, an existing folder can only be added to the solution with 
        /// all of sub-folders and files in it. Thus, if a single output file is 
        /// generated in an existing folders not in the solution, the target folder will 
        /// be added to the solution with all files in it, generated or not. The 
        /// only way to avoid this would be to immediately remove all child items 
        /// from a newly added existing folder. However, this could lead to having 
        /// orphaned files that were added to source control and later excluded from 
        /// the project. We may need to revisit this code and access <see cref="SourceControl"/> 
        /// automation model to remove the child items from source control too.
        /// </remarks>
        private ProjectItem AddFolder(ProjectItems collection, string folderName, string basePath)
        {
            // Does the folder already exist in the solution?
            ProjectItem folder = collection.Cast<ProjectItem>().FirstOrDefault(
                p => string.Compare(p.Name, folderName, StringComparison.OrdinalIgnoreCase) == 0);
            if (folder != null)
            {
                return folder;
            }

            try
            {
                // Try adding folder to the project.
                // Note that this will work for existing folder in a Database project but not in C#.
                return collection.AddFolder(folderName, Constants.vsProjectItemKindPhysicalFolder);
            }
            catch (COMException)
            {
                // If folder already exists on disk and the previous attempt to add failed
                string folderPath = Path.Combine(basePath, folderName);
                if (Directory.Exists(folderPath))
                {
                    // Try adding it from disk
                    // Note that this will work in a C# but is not implemented in Database projects.
                    return collection.AddFromDirectory(folderPath);
                }

                throw;
            }
        }

        /// <summary>
        /// Finds project item collection for the output file in Visual Studio solution.
        /// </summary>
        /// <param name="output">
        /// An <see cref="OutputFile"/> that needs to be added to the solution.
        /// </param>
        /// <param name="projects">
        /// All <see cref="Project"/>s in the current <see cref="Solution"/>.
        /// </param>
        /// <param name="template">
        /// A <see cref="ProjectItem"/> that represents T4 template being transformed.
        /// </param>
        /// <returns>A <see cref="ProjectItems"/> collection where the generated file should be added.</returns>
        private ProjectItems FindProjectItemCollection(OutputFile output, IEnumerable<Project> projects, ProjectItem template)
        {
            ProjectItems collection; // collection to which output file needs to be added
            string relativePath;     // path from the collection to the file
            string basePath;         // absolute path to the directory to which an item is being added

            if (!string.IsNullOrEmpty(output.Project))
            {
                // If output file needs to be added to another project
                Project project = projects.First(p => OutputInfo.SamePath(p.GetFilename(), output.Project));
                collection = project.ProjectItems;
                relativePath = Utils.GetRelativePath(project.GetFilename(), output.File);
                basePath = Path.GetDirectoryName(project.GetFilename());
            }
            else if (output.PreserveExistingFile
                || !OutputInfo.SamePath(Path.GetDirectoryName(output.File), Environment.CurrentDirectory))
            {
                // If output file needs to be added to another folder of the current project
                collection = template.ContainingProject.ProjectItems;
                relativePath = Utils.GetRelativePath(template.ContainingProject.GetFilename(), output.File);
                basePath = Path.GetDirectoryName(template.ContainingProject.GetFilename());
            }
            else
            {
                // Add the output file to the list of children of the template file
                collection = template.ProjectItems;
                relativePath = Utils.GetRelativePath(template.get_FileNames(1), output.File);
                basePath = Path.GetDirectoryName(template.get_FileNames(1));
            }

            // make sure that all folders in the file path exist in the project.
            if (relativePath.StartsWith("." + Path.DirectorySeparatorChar, StringComparison.Ordinal))
            {
                // Remove leading .\ from the path
                relativePath = relativePath.Substring(relativePath.IndexOf(Path.DirectorySeparatorChar) + 1);

                while (relativePath.Contains(Path.DirectorySeparatorChar))
                {
                    string folderName = relativePath.Substring(0, relativePath.IndexOf(Path.DirectorySeparatorChar));
                    ProjectItem folder = AddFolder(collection, folderName, basePath);

                    collection = folder.ProjectItems;
                    relativePath = relativePath.Substring(folderName.Length + 1);
                    basePath = Path.Combine(basePath, folderName);
                }
            }

            return collection;
        }
             
        void DeleteOldOutputs(IEnumerable<OutputFile> outputFiles, Solution solution, ProjectItem template)
        {
            foreach (var relativePath in _logStore.Read())
            {
                string absolutePath = Path.GetFullPath(relativePath);

                // Skip the file if it was regenerated during current transformation
                if (outputFiles.Any(output => OutputInfo.SamePath(output.File, absolutePath)))
                {
                    continue;
                }

                // The file wasn't regenerated, delete it from the solution, source control and file storage
                ProjectItem projectItem = solution.FindProjectItem(absolutePath);
                if (projectItem != null)
                {
                    projectItem.DeleteRecursive();
                }
            }
           
            // Also delete all project items nested under template if they weren't regenerated
            string templateFileName = Path.GetFileNameWithoutExtension(_templatePath);
            foreach (ProjectItem childProjectItem in template.ProjectItems)
            {
                // Skip the file if it has the same name as the template file. This will prevent constant
                // deletion and adding of the main output file to the project, which is slow and may require 
                // the user to check the file out unnecessarily.
                if (templateFileName == Path.GetFileNameWithoutExtension(childProjectItem.Name))
                {
                    continue;
                }

                // If the file wan't regenerated, delete it from the the solution, source control and file storage
                if (!outputFiles.Any(o => OutputInfo.SamePath(o.File, childProjectItem.get_FileNames(1))))
                {
                    childProjectItem.Delete();
                }
            }
        }
     
        /// <summary>
        /// Creates a new output file or updates it's contents if it already exists.
        /// </summary>
        /// <param name="output">An <see cref="OutputFile"/> object.</param>
        private void UpdateOutputFile(OutputFile output)
        {
            // Don't do anything unless the output file has changed and needs to be overwritten
            if (File.Exists(output.File))
            {
                if (output.PreserveExistingFile || output.Content.ToString() == File.ReadAllText(output.File, output.Encoding))
                {
                    return;
                }
            }

            // Check out the file if it is under source control
            SourceControl sourceControl = _dte.SourceControl;
            if (sourceControl.IsItemUnderSCC(output.File) && !sourceControl.IsItemCheckedOut(output.File))
            {
                sourceControl.CheckOutItem(output.File);
            }

            Directory.CreateDirectory(Path.GetDirectoryName(output.File));
            File.WriteAllText(output.File, output.Content.ToString(), output.Encoding);
        }

        /// <summary>
        /// Performs validation tasks that require accessing Visual Studio automation model.
        /// </summary>
        /// <param name="outputFiles">
        /// <see cref="OutputFile"/>s that need to be added to the solution.
        /// </param>
        /// <param name="projects">
        /// All <see cref="Project"/>s in the current solution.
        /// </param>
        /// <remarks>
        /// Most of the output validation is done on the fly, by the <see cref="OutputManager.Append"/>
        /// method. This method performs the remaining validations that access Visual 
        /// Studio automation model and cannot cross <see cref="AppDomain"/> boundries.
        /// </remarks>
        private void Validate(IEnumerable<OutputFile> outputFiles, IEnumerable<Project> projects)
        {
            foreach (OutputFile outputFile in outputFiles)
            {
                if (string.IsNullOrEmpty(outputFile.Project))
                    continue;

                // Make sure that project is included in the solution
                bool projectInSolution = projects.Any(p => OutputInfo.SamePath(p.GetFilename(), outputFile.Project));
                if (!projectInSolution)
                {
                    //var projectInfoCollection = string.Join(", ", projects.Select(x => x.GetFilename()));

                    //throw new Exception(
                    //string.Format(CultureInfo.CurrentCulture, 
                    //    "Target project {0} does not belong to the solution {1}", 
                    //    outputFile.Project,
                    //    projectInfoCollection));


                    throw new Exception(
                        string.Format(CultureInfo.CurrentCulture, "Target project {0} does not belong to the solution", outputFile.Project));
                }
            }
        }
    }    
}